<!DOCTYPE html>
<html lang="zh-cn">
<head>
  <title>实现 RESTful API - 为企业级框架和应用而生</title>
  <meta charset="utf-8">
  <meta name="description" content="index.description">
  <meta http-equiv="X-UA-Compatible" content="IE=Edge,chrome=1">
  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
  <link rel="icon" href="/images/favicon.png" type="image/x-icon">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/docsearch.js/2/docsearch.min.css" />
<link rel="stylesheet" href="/css/index.css">

    <script>
    !function(t,e,a,r,c){t.TracertCmdCache=t.TracertCmdCache||[],t[c]=window[c]||
      {_isInit:!0,call:function(){t.TracertCmdCache.push(arguments)},
      start:function(t){this.call('start',t)}},t[c].l=new Date;
      var n=e.createElement(a),s=e.getElementsByTagName(a)[0];
      n.async=!0,n.src=r,s.parentNode.insertBefore(n,s)}
    (window,document,'script','https://tracert.alipay.com/tracert.js','Tracert');
      Tracert.start({
        plugins: [ 'BucName' ],
        spmAPos: 'a454',
        spmBPos: 'b4893',
      });
    </script>
  
</head>
<body>
  <div class="nav" >
  <header>
    <a href="/zh-cn/" class="nav-logo leftpadding" alt="egg"><img src="https://zos.alipayobjects.com/rmsportal/VTcUYAaoKqXyHJbLAPyF.svg"></a>
    <ul class="nav-item">
      <li>
        <form id="search-form">
          <input type="text" id="search-query" class="search-query st-default-search-input">
        </form>
      </li>
      <li><a href="/zh-cn/intro/" alt="指南">指南</a></li><li><a href="/api/" alt="API">API</a></li><li><a href="/zh-cn/tutorials/index.html" alt="教程">教程</a></li><li><a href="https://github.com/search?q=topic%3Aegg-plugin&type=Repositories" alt="插件">插件</a></li><li><a href="https://github.com/eggjs/egg/releases" alt="发布日志">发布日志</a></li>
      
      
        <li class="translations">
          <a class="nav-link">Translations</a>
          <span class="arrow"></span><ul id="dropdownContent" class="dropdown-content"><li><a id="en" href="/en/tutorials/restful.html" >English</a></li><li><a id="zh-cn" href="/zh-cn/tutorials/restful.html" style="color: #22ab28">中文</a></li></ul>
        </li>
      
      <li><iframe src="https://ghbtns.com/github-btn.html?user=eggjs&repo=egg&type=star&count=true" frameborder="0" scrolling="0" width="150px" height="20px"></iframe></li>
    </ul>
    <a id="mobileTrigger" href="#" class="mobile-trigger">
      <ul>
        <li></li>
        <li></li>
        <li></li>
      </ul>
    </a>
  </header>
</div>
  <div id="container" class="container">
    <div class="page-main">
  <article class="markdown-body">
    <h1>实现 RESTful API</h1>
    <p>通过 Web 技术开发服务给客户端提供接口，可能是各个 Web 框架最广泛的应用之一。这篇文章我们拿 <a href="https://cnodejs.org/" target="_blank" rel="noopener">CNode 社区</a> 的接口来看一看通过 Egg 如何实现 <a href="https://zh.wikipedia.org/wiki/REST" target="_blank" rel="noopener">RESTful</a> API 给客户端调用。</p>
<p>CNode 社区现在 v1 版本的接口不是完全符合 RESTful 语义，在这篇文章中，我们将基于 CNode V1 的接口，封装一个更符合 RESTful 语义的 V2 版本 API。</p>
<h2 id="设计响应格式"><a class="markdown-anchor" href="#设计响应格式">#</a> 设计响应格式</h2>
<p>在 RESTful 风格的设计中，我们会通过响应状态码来标识响应的状态，保持响应的 body 简洁，只返回接口数据。以 <code>topics</code> 资源为例：</p>
<h3 id="获取主题列表"><a class="markdown-anchor" href="#获取主题列表">#</a> 获取主题列表</h3>
<ul>
<li><code>GET /api/v2/topics</code></li>
<li>响应状态码：200</li>
<li>响应体：</li>
</ul>
<figure class="highlight json"><table><tr><td class="code"><pre><span class="line">[</span><br><span class="line">  &#123;</span><br><span class="line">    <span class="attr">"id"</span>: <span class="string">"57ea257b3670ca3f44c5beb6"</span>,</span><br><span class="line">    <span class="attr">"author_id"</span>: <span class="string">"541bf9b9ad60405c1f151a03"</span>,</span><br><span class="line">    <span class="attr">"tab"</span>: <span class="string">"share"</span>,</span><br><span class="line">    <span class="attr">"content"</span>: <span class="string">"content"</span>,</span><br><span class="line">    <span class="attr">"last_reply_at"</span>: <span class="string">"2017-01-11T13:32:25.089Z"</span>,</span><br><span class="line">    <span class="attr">"good"</span>: <span class="literal">false</span>,</span><br><span class="line">    <span class="attr">"top"</span>: <span class="literal">true</span>,</span><br><span class="line">    <span class="attr">"reply_count"</span>: <span class="number">155</span>,</span><br><span class="line">    <span class="attr">"visit_count"</span>: <span class="number">28176</span>,</span><br><span class="line">    <span class="attr">"create_at"</span>: <span class="string">"2016-09-27T07:53:31.872Z"</span>,</span><br><span class="line">  &#125;,</span><br><span class="line">  &#123;</span><br><span class="line">    <span class="attr">"id"</span>: <span class="string">"57ea257b3670ca3f44c5beb6"</span>,</span><br><span class="line">    <span class="attr">"author_id"</span>: <span class="string">"541bf9b9ad60405c1f151a03"</span>,</span><br><span class="line">    <span class="attr">"tab"</span>: <span class="string">"share"</span>,</span><br><span class="line">    <span class="attr">"content"</span>: <span class="string">"content"</span>,</span><br><span class="line">    <span class="attr">"title"</span>: <span class="string">"《一起学 Node.js》彻底重写完毕"</span>,</span><br><span class="line">    <span class="attr">"last_reply_at"</span>: <span class="string">"2017-01-11T10:20:56.496Z"</span>,</span><br><span class="line">    <span class="attr">"good"</span>: <span class="literal">false</span>,</span><br><span class="line">    <span class="attr">"top"</span>: <span class="literal">true</span>,</span><br><span class="line">    <span class="attr">"reply_count"</span>: <span class="number">193</span>,</span><br><span class="line">    <span class="attr">"visit_count"</span>: <span class="number">47633</span>,</span><br><span class="line">  &#125;,</span><br><span class="line">]</span><br></pre></td></tr></table></figure>
<h3 id="获取单个主题"><a class="markdown-anchor" href="#获取单个主题">#</a> 获取单个主题</h3>
<ul>
<li><code>GET /api/v2/topics/57ea257b3670ca3f44c5beb6</code></li>
<li>响应状态码：200</li>
<li>响应体：</li>
</ul>
<figure class="highlight json"><table><tr><td class="code"><pre><span class="line">&#123;</span><br><span class="line">  <span class="attr">"id"</span>: <span class="string">"57ea257b3670ca3f44c5beb6"</span>,</span><br><span class="line">  <span class="attr">"author_id"</span>: <span class="string">"541bf9b9ad60405c1f151a03"</span>,</span><br><span class="line">  <span class="attr">"tab"</span>: <span class="string">"share"</span>,</span><br><span class="line">  <span class="attr">"content"</span>: <span class="string">"content"</span>,</span><br><span class="line">  <span class="attr">"title"</span>: <span class="string">"《一起学 Node.js》彻底重写完毕"</span>,</span><br><span class="line">  <span class="attr">"last_reply_at"</span>: <span class="string">"2017-01-11T10:20:56.496Z"</span>,</span><br><span class="line">  <span class="attr">"good"</span>: <span class="literal">false</span>,</span><br><span class="line">  <span class="attr">"top"</span>: <span class="literal">true</span>,</span><br><span class="line">  <span class="attr">"reply_count"</span>: <span class="number">193</span>,</span><br><span class="line">  <span class="attr">"visit_count"</span>: <span class="number">47633</span>,</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure>
<h3 id="创建主题"><a class="markdown-anchor" href="#创建主题">#</a> 创建主题</h3>
<ul>
<li><code>POST /api/v2/topics</code></li>
<li>响应状态码：201</li>
<li>响应体：</li>
</ul>
<figure class="highlight plain"><table><tr><td class="code"><pre><span class="line">&#123;</span><br><span class="line">  &quot;topic_id&quot;: &quot;57ea257b3670ca3f44c5beb6&quot;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure>
<h3 id="更新主题"><a class="markdown-anchor" href="#更新主题">#</a> 更新主题</h3>
<ul>
<li><code>PUT /api/v2/topics/57ea257b3670ca3f44c5beb6</code></li>
<li>响应状态码：204</li>
<li>响应体：空</li>
</ul>
<h3 id="错误处理"><a class="markdown-anchor" href="#错误处理">#</a> 错误处理</h3>
<p>在接口处理发生错误的时候，如果是客户端请求参数导致的错误，我们会返回 4xx 状态码，如果是服务端自身的处理逻辑错误，我们会返回 5xx 状态码。所有的异常对象都是对这个异常状态的描述，其中 error 字段是错误的描述，detail 字段（可选）是导致错误的详细原因。</p>
<p>例如，当客户端传递的参数异常时，我们可能返回一个响应，状态码为 422，返回响应体为：</p>
<figure class="highlight json"><table><tr><td class="code"><pre><span class="line">&#123;</span><br><span class="line">  <span class="attr">"error"</span>: <span class="string">"Validation Failed"</span>,</span><br><span class="line">  <span class="attr">"detail"</span>: [ &#123; <span class="attr">"message"</span>: <span class="string">"required"</span>, <span class="attr">"field"</span>: <span class="string">"title"</span>, <span class="attr">"code"</span>: <span class="string">"missing_field"</span> &#125; ]</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure>
<h2 id="实现"><a class="markdown-anchor" href="#实现">#</a> 实现</h2>
<p>在约定好接口之后，我们可以开始动手实现了。</p>
<h3 id="初始化项目"><a class="markdown-anchor" href="#初始化项目">#</a> 初始化项目</h3>
<p>还是通过<a href="../intro/quickstart.html">快速入门</a>章节介绍的 <a href="https://github.com/eggjs/egg-init" target="_blank" rel="noopener">egg-init</a> 工具来初始化我们的应用</p>
<figure class="highlight bash"><table><tr><td class="code"><pre><span class="line">$ egg-init cnode-api --<span class="built_in">type</span>=simple</span><br><span class="line">$ <span class="built_in">cd</span> cnode-api</span><br><span class="line">$ npm i</span><br></pre></td></tr></table></figure>
<h3 id="开启-validate-插件"><a class="markdown-anchor" href="#开启-validate-插件">#</a> 开启 validate 插件</h3>
<p>我们选择 <a href="https://github.com/eggjs/egg-validate" target="_blank" rel="noopener">egg-validate</a> 作为 validate 插件的示例。</p>
<figure class="highlight js"><table><tr><td class="code"><pre><span class="line"><span class="comment">// config/plugin.js</span></span><br><span class="line">exports.validate = &#123;</span><br><span class="line">  enable: <span class="literal">true</span>,</span><br><span class="line">  package: <span class="string">'egg-validate'</span>,</span><br><span class="line">&#125;;</span><br></pre></td></tr></table></figure>
<h3 id="注册路由"><a class="markdown-anchor" href="#注册路由">#</a> 注册路由</h3>
<p>首先，我们先按照前面的设计来注册<a href="../basics/router.html">路由</a>，框架提供了一个便捷的方式来创建 RESTful 风格的路由，并将一个资源的接口映射到对应的 controller 文件。在 <code>app/router.js</code> 中：</p>
<figure class="highlight js"><table><tr><td class="code"><pre><span class="line"><span class="comment">// app/router.js</span></span><br><span class="line"><span class="built_in">module</span>.exports = <span class="function"><span class="params">app</span> =&gt;</span> &#123;</span><br><span class="line">  app.router.resources(<span class="string">'topics'</span>, <span class="string">'/api/v2/topics'</span>, app.controller.topics);</span><br><span class="line">&#125;;</span><br></pre></td></tr></table></figure>
<p>通过 <code>app.resources</code> 方法，我们将 topics 这个资源的增删改查接口映射到了 <code>app/controller/topics.js</code> 文件。</p>
<h3 id="controller-开发"><a class="markdown-anchor" href="#controller-开发">#</a> controller 开发</h3>
<p>在 <a href="../basics/controller.html">controller</a> 中，我们只需要实现 <code>app.resources</code> 约定的 <a href="../basics/router.html#restful-%E9%A3%8E%E6%A0%BC%E7%9A%84-url-%E5%AE%9A%E4%B9%89">RESTful 风格的 URL 定义</a> 中我们需要提供的接口即可。例如我们来实现创建一个 topics 的接口：</p>
<figure class="highlight js"><table><tr><td class="code"><pre><span class="line"><span class="comment">// app/controller/topics.js</span></span><br><span class="line"><span class="keyword">const</span> Controller = <span class="built_in">require</span>(<span class="string">'egg'</span>).Controller;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 定义创建接口的请求参数规则</span></span><br><span class="line"><span class="keyword">const</span> createRule = &#123;</span><br><span class="line">  accesstoken: <span class="string">'string'</span>,</span><br><span class="line">  title: <span class="string">'string'</span>,</span><br><span class="line">  tab: &#123; <span class="attr">type</span>: <span class="string">'enum'</span>, <span class="attr">values</span>: [ <span class="string">'ask'</span>, <span class="string">'share'</span>, <span class="string">'job'</span> ], <span class="attr">required</span>: <span class="literal">false</span> &#125;,</span><br><span class="line">  content: <span class="string">'string'</span>,</span><br><span class="line">&#125;;</span><br><span class="line"></span><br><span class="line"><span class="class"><span class="keyword">class</span> <span class="title">TopicController</span> <span class="keyword">extends</span> <span class="title">Controller</span> </span>&#123;</span><br><span class="line">  <span class="keyword">async</span> create() &#123;</span><br><span class="line">    <span class="keyword">const</span> ctx = <span class="keyword">this</span>.ctx;</span><br><span class="line">    <span class="comment">// 校验 `ctx.request.body` 是否符合我们预期的格式</span></span><br><span class="line">    <span class="comment">// 如果参数校验未通过，将会抛出一个 status = 422 的异常</span></span><br><span class="line">    ctx.validate(createRule);</span><br><span class="line">    <span class="comment">// 调用 service 创建一个 topic</span></span><br><span class="line">    <span class="keyword">const</span> id = <span class="keyword">await</span> ctx.service.topics.create(ctx.request.body);</span><br><span class="line">    <span class="comment">// 设置响应体和状态码</span></span><br><span class="line">    ctx.body = &#123;</span><br><span class="line">      topic_id: id,</span><br><span class="line">    &#125;;</span><br><span class="line">    ctx.status = <span class="number">201</span>;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br><span class="line"><span class="built_in">module</span>.exports = TopicController;</span><br></pre></td></tr></table></figure>
<p>如同注释中说明的，一个 Controller 主要实现了下面的逻辑：</p>
<ol>
<li>调用 validate 方法对请求参数进行验证。</li>
<li>用验证过的参数调用 service 封装的业务逻辑来创建一个 topic。</li>
<li>按照接口约定的格式设置响应状态码和内容。</li>
</ol>
<h3 id="service-开发"><a class="markdown-anchor" href="#service-开发">#</a> service 开发</h3>
<p>在 <a href="../basics/service.html">service</a> 中，我们可以更加专注的编写实际生效的业务逻辑。</p>
<figure class="highlight js"><table><tr><td class="code"><pre><span class="line"><span class="comment">// app/service/topics.js</span></span><br><span class="line"><span class="keyword">const</span> Service = <span class="built_in">require</span>(<span class="string">'egg'</span>).Service;</span><br><span class="line"></span><br><span class="line"><span class="class"><span class="keyword">class</span> <span class="title">TopicService</span> <span class="keyword">extends</span> <span class="title">Service</span> </span>&#123;</span><br><span class="line">  <span class="keyword">constructor</span>(ctx) &#123;</span><br><span class="line">    <span class="keyword">super</span>(ctx);</span><br><span class="line">    <span class="keyword">this</span>.root = <span class="string">'https://cnodejs.org/api/v1'</span>;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">async</span> create(params) &#123;</span><br><span class="line">    <span class="comment">// 调用 CNode V1 版本 API</span></span><br><span class="line">    <span class="keyword">const</span> result = <span class="keyword">await</span> <span class="keyword">this</span>.ctx.curl(<span class="string">`<span class="subst">$&#123;<span class="keyword">this</span>.root&#125;</span>/topics`</span>, &#123;</span><br><span class="line">      method: <span class="string">'post'</span>,</span><br><span class="line">      data: params,</span><br><span class="line">      dataType: <span class="string">'json'</span>,</span><br><span class="line">      contentType: <span class="string">'json'</span>,</span><br><span class="line">    &#125;);</span><br><span class="line">    <span class="comment">// 检查调用是否成功，如果调用失败会抛出异常</span></span><br><span class="line">    <span class="keyword">this</span>.checkSuccess(result);</span><br><span class="line">    <span class="comment">// 返回创建的 topic 的 id</span></span><br><span class="line">    <span class="keyword">return</span> result.data.topic_id;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="comment">// 封装统一的调用检查函数，可以在查询、创建和更新等 Service 中复用</span></span><br><span class="line">  checkSuccess(result) &#123;</span><br><span class="line">    <span class="keyword">if</span> (result.status !== <span class="number">200</span>) &#123;</span><br><span class="line">      <span class="keyword">const</span> errorMsg = result.data &amp;&amp; result.data.error_msg ? result.data.error_msg : <span class="string">'unknown error'</span>;</span><br><span class="line">      <span class="keyword">this</span>.ctx.throw(result.status, errorMsg);</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">if</span> (!result.data.success) &#123;</span><br><span class="line">      <span class="comment">// 远程调用返回格式错误</span></span><br><span class="line">      <span class="keyword">this</span>.ctx.throw(<span class="number">500</span>, <span class="string">'remote response error'</span>, &#123; <span class="attr">data</span>: result.data &#125;);</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="built_in">module</span>.exports = TopicService;</span><br></pre></td></tr></table></figure>
<p>在创建 topic 的 Service 开发完成之后，我们就从上往下的完成了一个接口的开发。</p>
<h3 id="统一错误处理"><a class="markdown-anchor" href="#统一错误处理">#</a> 统一错误处理</h3>
<p>正常的业务逻辑已经正常完成了，但是异常我们还没有进行处理。在前面编写的代码中，Controller 和 Service 都有可能抛出异常，这也是我们推荐的编码方式，当发现客户端参数传递错误或者调用后端服务异常时，通过抛出异常的方式来进行中断。</p>
<ul>
<li>Controller 中 <code>this.ctx.validate()</code> 进行参数校验，失败抛出异常。</li>
<li>Service 中调用 <code>this.ctx.curl()</code> 方法访问 CNode 服务，可能由于网络问题等原因抛出服务端异常。</li>
<li>Service 中拿到 CNode 服务端返回的结果后，可能会收到请求调用失败的返回结果，此时也会抛出异常。</li>
</ul>
<p>框架虽然提供了默认的异常处理，但是可能和我们在前面的接口约定不一致，因此我们需要自己实现一个统一错误处理的中间件来对错误进行处理。</p>
<p>在 <code>app/middleware</code> 目录下新建一个 <code>error_handler.js</code> 的文件来新建一个 <a href="../basics/middleware.html">middleware</a></p>
<figure class="highlight js"><table><tr><td class="code"><pre><span class="line"><span class="comment">// app/middleware/error_handler.js</span></span><br><span class="line"><span class="built_in">module</span>.exports = <span class="function"><span class="params">()</span> =&gt;</span> &#123;</span><br><span class="line">  <span class="keyword">return</span> <span class="keyword">async</span> <span class="function"><span class="keyword">function</span> <span class="title">errorHandler</span>(<span class="params">ctx, next</span>) </span>&#123;</span><br><span class="line">    <span class="keyword">try</span> &#123;</span><br><span class="line">      <span class="keyword">await</span> next();</span><br><span class="line">    &#125; <span class="keyword">catch</span> (err) &#123;</span><br><span class="line">      <span class="comment">// 所有的异常都在 app 上触发一个 error 事件，框架会记录一条错误日志</span></span><br><span class="line">      ctx.app.emit(<span class="string">'error'</span>, err, ctx);</span><br><span class="line"></span><br><span class="line">      <span class="keyword">const</span> status = err.status || <span class="number">500</span>;</span><br><span class="line">      <span class="comment">// 生产环境时 500 错误的详细错误内容不返回给客户端，因为可能包含敏感信息</span></span><br><span class="line">      <span class="keyword">const</span> error = status === <span class="number">500</span> &amp;&amp; ctx.app.config.env === <span class="string">'prod'</span></span><br><span class="line">        ? <span class="string">'Internal Server Error'</span></span><br><span class="line">        : err.message;</span><br><span class="line"></span><br><span class="line">      <span class="comment">// 从 error 对象上读出各个属性，设置到响应中</span></span><br><span class="line">      ctx.body = &#123; error &#125;;</span><br><span class="line">      <span class="keyword">if</span> (status === <span class="number">422</span>) &#123;</span><br><span class="line">        ctx.body.detail = err.errors;</span><br><span class="line">      &#125;</span><br><span class="line">      ctx.status = status;</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;;</span><br><span class="line">&#125;;</span><br></pre></td></tr></table></figure>
<p>通过这个中间件，我们可以捕获所有异常，并按照我们想要的格式封装了响应。将这个中间件通过配置文件(<code>config/config.default.js</code>)加载进来：</p>
<figure class="highlight js"><table><tr><td class="code"><pre><span class="line"><span class="comment">// config/config.default.js</span></span><br><span class="line"><span class="built_in">module</span>.exports = &#123;</span><br><span class="line">  <span class="comment">// 加载 errorHandler 中间件</span></span><br><span class="line">  middleware: [ <span class="string">'errorHandler'</span> ],</span><br><span class="line">  <span class="comment">// 只对 /api 前缀的 url 路径生效</span></span><br><span class="line">  errorHandler: &#123;</span><br><span class="line">    match: <span class="string">'/api'</span>,</span><br><span class="line">  &#125;,</span><br><span class="line">&#125;;</span><br></pre></td></tr></table></figure>
<h2 id="测试"><a class="markdown-anchor" href="#测试">#</a> 测试</h2>
<p>代码完成只是第一步，我们还需要给代码加上<a href="../core/unittest.html">单元测试</a>。</p>
<h3 id="controller-测试"><a class="markdown-anchor" href="#controller-测试">#</a> Controller 测试</h3>
<p>我们先来编写 Controller 代码的单元测试。在写 Controller 单测的时候，我们可以适时的模拟 Service 层的实现，因为对 Controller 的单元测试而言，最重要的部分是测试自身的逻辑，而 Service 层按照约定的接口 mock 掉，Service 自身的逻辑可以让 Service 的单元测试来覆盖，这样我们开发的时候也可以分层进行开发测试。</p>
<figure class="highlight js"><table><tr><td class="code"><pre><span class="line"><span class="keyword">const</span> &#123; app, mock, assert &#125; = <span class="built_in">require</span>(<span class="string">'egg-mock/bootstrap'</span>);</span><br><span class="line"></span><br><span class="line">describe(<span class="string">'test/app/controller/topics.test.js'</span>, () =&gt; &#123;</span><br><span class="line">  <span class="comment">// 测试请求参数错误时应用的响应</span></span><br><span class="line">  it(<span class="string">'should POST /api/v2/topics/ 422'</span>, () =&gt; &#123;</span><br><span class="line">    app.mockCsrf();</span><br><span class="line">    <span class="keyword">return</span> app.httpRequest()</span><br><span class="line">      .post(<span class="string">'/api/v2/topics'</span>)</span><br><span class="line">      .send(&#123;</span><br><span class="line">        accesstoken: <span class="string">'123'</span>,</span><br><span class="line">      &#125;)</span><br><span class="line">      .expect(<span class="number">422</span>)</span><br><span class="line">      .expect(&#123;</span><br><span class="line">        error: <span class="string">'Validation Failed'</span>,</span><br><span class="line">        detail: [</span><br><span class="line">          &#123; <span class="attr">message</span>: <span class="string">'required'</span>, <span class="attr">field</span>: <span class="string">'title'</span>, <span class="attr">code</span>: <span class="string">'missing_field'</span> &#125;,</span><br><span class="line">          &#123; <span class="attr">message</span>: <span class="string">'required'</span>, <span class="attr">field</span>: <span class="string">'content'</span>, <span class="attr">code</span>: <span class="string">'missing_field'</span> &#125;,</span><br><span class="line">        ],</span><br><span class="line">      &#125;);</span><br><span class="line">  &#125;);</span><br><span class="line"></span><br><span class="line">  <span class="comment">// mock 掉 service 层，测试正常时的返回</span></span><br><span class="line">  it(<span class="string">'should POST /api/v2/topics/ 201'</span>, () =&gt; &#123;</span><br><span class="line">    app.mockCsrf();</span><br><span class="line">    app.mockService(<span class="string">'topics'</span>, <span class="string">'create'</span>, <span class="number">123</span>);</span><br><span class="line">    <span class="keyword">return</span> app.httpRequest()</span><br><span class="line">      .post(<span class="string">'/api/v2/topics'</span>)</span><br><span class="line">      .send(&#123;</span><br><span class="line">        accesstoken: <span class="string">'123'</span>,</span><br><span class="line">        title: <span class="string">'title'</span>,</span><br><span class="line">        content: <span class="string">'hello'</span>,</span><br><span class="line">      &#125;)</span><br><span class="line">      .expect(<span class="number">201</span>)</span><br><span class="line">      .expect(&#123;</span><br><span class="line">        topic_id: <span class="number">123</span>,</span><br><span class="line">      &#125;);</span><br><span class="line">  &#125;);</span><br><span class="line">&#125;);</span><br></pre></td></tr></table></figure>
<p>上面对 Controller 的测试中，我们通过 <a href="https://github.com/eggjs/egg-mock" target="_blank" rel="noopener">egg-mock</a> 创建了一个应用，并通过 <a href="https://github.com/visionmedia/supertest" target="_blank" rel="noopener">SuperTest</a> 来模拟客户端发送请求进行测试。在测试中我们会模拟 Service 层的响应来测试 Controller 层的处理逻辑。</p>
<h3 id="service-测试"><a class="markdown-anchor" href="#service-测试">#</a> Service 测试</h3>
<p>Service 层的测试也只需要聚焦于自身的代码逻辑，<a href="https://github.com/eggjs/egg-mock" target="_blank" rel="noopener">egg-mock</a> 同样提供了快速测试 Service 的方法，不再需要用 SuperTest 模拟从客户端发起请求，而是直接调用 Service 中的方法进行测试。</p>
<figure class="highlight js"><table><tr><td class="code"><pre><span class="line"><span class="keyword">const</span> &#123; app, mock, assert &#125; = <span class="built_in">require</span>(<span class="string">'egg-mock/bootstrap'</span>);</span><br><span class="line"></span><br><span class="line">describe(<span class="string">'test/app/service/topics.test.js'</span>, () =&gt; &#123;</span><br><span class="line">  <span class="keyword">let</span> ctx;</span><br><span class="line"></span><br><span class="line">  beforeEach(<span class="function"><span class="params">()</span> =&gt;</span> &#123;</span><br><span class="line">    <span class="comment">// 创建一个匿名的 context 对象，可以在 ctx 对象上调用 service 的方法</span></span><br><span class="line">    ctx = app.mockContext();</span><br><span class="line">  &#125;);</span><br><span class="line"></span><br><span class="line">  describe(<span class="string">'create()'</span>, () =&gt; &#123;</span><br><span class="line">    it(<span class="string">'should create failed by accesstoken error'</span>, <span class="keyword">async</span> () =&gt; &#123;</span><br><span class="line">      <span class="keyword">try</span> &#123;</span><br><span class="line">        <span class="keyword">await</span> ctx.service.topics.create(&#123;</span><br><span class="line">          accesstoken: <span class="string">'hello'</span>,</span><br><span class="line">          title: <span class="string">'title'</span>,</span><br><span class="line">          content: <span class="string">'content'</span>,</span><br><span class="line">        &#125;);</span><br><span class="line">      &#125; <span class="keyword">catch</span> (err) &#123;</span><br><span class="line">        assert(err.status === <span class="number">401</span>);</span><br><span class="line">        assert(err.message === <span class="string">'错误的accessToken'</span>);</span><br><span class="line">        <span class="keyword">return</span>;</span><br><span class="line">      &#125;</span><br><span class="line">      <span class="keyword">throw</span> <span class="string">'should not run here'</span>;</span><br><span class="line">    &#125;);</span><br><span class="line"></span><br><span class="line">    it(<span class="string">'should create success'</span>, <span class="keyword">async</span> () =&gt; &#123;</span><br><span class="line">      <span class="comment">// 不影响 CNode 的正常运行，我们可以将对 CNode 的调用按照接口约定模拟掉</span></span><br><span class="line">      <span class="comment">// app.mockHttpclient 方法可以便捷的对应用发起的 http 请求进行模拟</span></span><br><span class="line">      app.mockHttpclient(<span class="string">`<span class="subst">$&#123;ctx.service.topics.root&#125;</span>/topics`</span>, <span class="string">'POST'</span>, &#123;</span><br><span class="line">        data: &#123;</span><br><span class="line">          success: <span class="literal">true</span>,</span><br><span class="line">          topic_id: <span class="string">'5433d5e4e737cbe96dcef312'</span>,</span><br><span class="line">        &#125;,</span><br><span class="line">      &#125;);</span><br><span class="line"></span><br><span class="line">      <span class="keyword">const</span> id = <span class="keyword">await</span> ctx.service.topics.create(&#123;</span><br><span class="line">        accesstoken: <span class="string">'hello'</span>,</span><br><span class="line">        title: <span class="string">'title'</span>,</span><br><span class="line">        content: <span class="string">'content'</span>,</span><br><span class="line">      &#125;);</span><br><span class="line">      assert(id === <span class="string">'5433d5e4e737cbe96dcef312'</span>);</span><br><span class="line">    &#125;);</span><br><span class="line">  &#125;);</span><br><span class="line">&#125;);</span><br></pre></td></tr></table></figure>
<p>上面对 Service 层的测试中，我们通过 egg-mock 提供的 <code>app.createContext()</code> 方法创建了一个 Context 对象，并直接调用 Context 上的 Service 方法进行测试，测试时可以通过 <code>app.mockHttpclient()</code> 方法模拟 HTTP 调用的响应，让我们剥离环境的影响而专注于 Service 自身逻辑的测试上。</p>
<hr>
<p>完整的代码实现和测试都在 <a href="https://github.com/eggjs/examples/tree/master/cnode-api" target="_blank" rel="noopener">eggjs/examples/cnode-api</a> 中可以找到。</p>

  </article>
  <aside id="mobileAside" class="toc">
  <div class="mobile-menu">
    <ul>
      <li><a href="/zh-cn/intro/" alt="指南">指南</a></li><li><a href="/api/" alt="API">API</a></li><li><a href="/zh-cn/tutorials/index.html" alt="教程">教程</a></li><li><a href="https://github.com/search?q=topic%3Aegg-plugin&type=Repositories" alt="插件">插件</a></li><li><a href="https://github.com/eggjs/egg/releases" alt="发布日志">发布日志</a></li>
      
      
        <li class="translations">
          <a class="nav-link">Translations</a>
          <span class="arrow"></span><ul id="dropdownContent" class="dropdown-content"><li><a id="en" href="/en/tutorials/restful.html" >English</a></li><li><a id="zh-cn" href="/zh-cn/tutorials/restful.html" style="color: #22ab28">中文</a></li></ul>
        </li>
      
    </ul>
  </div>
  <dl><dt id="title-Intro" style="cursor: pointer;" class="aside-title">新手指南<a id="collapse-icon-Intro" class="icon opend"></a></dt><dd id=panel-Intro><ul><li><a href="/zh-cn/intro/index.html" class="menu-link">Egg.js 是什么?</a></li><li><a href="/zh-cn/intro/egg-and-koa.html" class="menu-link">Egg.js 和 Koa</a></li><li><a href="/zh-cn/intro/quickstart.html" class="menu-link">快速入门</a></li><li><a href="/zh-cn/tutorials/progressive.html" class="menu-link">渐进式开发</a></li><li><a href="/zh-cn/migration.html" class="menu-link">2.x 升级指南</a></li></ul></dd><dt id="title-Basics" style="cursor: pointer;" class="aside-title">基础功能<a id="collapse-icon-Basics" class="icon opend"></a></dt><dd id=panel-Basics><ul><li><a href="/zh-cn/basics/structure.html" class="menu-link">目录结构</a></li><li><a href="/zh-cn/basics/objects.html" class="menu-link">内置对象</a></li><li><a href="/zh-cn/basics/env.html" class="menu-link">运行环境</a></li><li><a href="/zh-cn/basics/config.html" class="menu-link">配置</a></li><li><a href="/zh-cn/basics/middleware.html" class="menu-link">中间件</a></li><li><a href="/zh-cn/basics/router.html" class="menu-link">Router</a></li><li><a href="/zh-cn/basics/controller.html" class="menu-link">Controller</a></li><li><a href="/zh-cn/basics/service.html" class="menu-link">Service</a></li><li><a href="/zh-cn/basics/plugin.html" class="menu-link">插件</a></li><li><a href="/zh-cn/basics/schedule.html" class="menu-link">定时任务</a></li><li><a href="/zh-cn/basics/extend.html" class="menu-link">框架扩展</a></li><li><a href="/zh-cn/basics/app-start.html" class="menu-link">启动自定义</a></li></ul></dd><dt id="title-Core" style="cursor: pointer;" class="aside-title">核心功能<a id="collapse-icon-Core" class="icon opend"></a></dt><dd id=panel-Core><ul><li><a href="/zh-cn/core/development.html" class="menu-link">本地开发</a></li><li><a href="/zh-cn/core/unittest.html" class="menu-link">单元测试</a></li><li><a href="/zh-cn/core/deployment.html" class="menu-link">应用部署</a></li><li><a href="/zh-cn/core/logger.html" class="menu-link">日志</a></li><li><a href="/zh-cn/core/httpclient.html" class="menu-link">HttpClient</a></li><li><a href="/zh-cn/core/cookie-and-session.html" class="menu-link">Cookie and Session</a></li><li><a href="/zh-cn/core/cluster-and-ipc.html" class="menu-link">多进程模型和进程间通讯</a></li><li><a href="/zh-cn/core/view.html" class="menu-link">模板渲染</a></li><li><a href="/zh-cn/core/error-handling.html" class="menu-link">异常处理</a></li><li><a href="/zh-cn/core/security.html" class="menu-link">安全</a></li><li><a href="/zh-cn/core/i18n.html" class="menu-link">国际化</a></li></ul></dd><dt id="title-Tutorials" style="cursor: pointer;" class="aside-title">教程<a id="collapse-icon-Tutorials" class="icon opend"></a></dt><dd id=panel-Tutorials><ul><li><a href="/zh-cn/tutorials/mysql.html" class="menu-link">MySQL</a></li><li><a href="/zh-cn/tutorials/restful.html" class="menu-link">RESTful API</a></li><li><a href="/zh-cn/tutorials/passport.html" class="menu-link">Passport 鉴权</a></li><li><a href="/zh-cn/tutorials/socketio.html" class="menu-link">Socket.IO</a></li><li><a href="/zh-cn/tutorials/assets.html" class="menu-link">静态资源</a></li><li><a href="/zh-cn/tutorials/typescript.html" class="menu-link">TypeScript</a></li></ul></dd><dt id="title-Advanced" style="cursor: pointer;" class="aside-title">进阶<a id="collapse-icon-Advanced" class="icon opend"></a></dt><dd id=panel-Advanced><ul><li><a href="/zh-cn/advanced/loader.html" class="menu-link">Loader</a></li><li><a href="/zh-cn/advanced/plugin.html" class="menu-link">插件开发</a></li><li><a href="/zh-cn/advanced/framework.html" class="menu-link">框架开发</a></li><li><a href="/zh-cn/advanced/cluster-client.html" class="menu-link">多进程研发模式增强</a></li><li><a href="/zh-cn/advanced/view-plugin.html" class="menu-link">模板插件开发规范</a></li><li><a href="/zh-cn/style-guide.html" class="menu-link">代码风格指南</a></li></ul></dd><dt id="title-Community" style="cursor: pointer;" class="aside-title">社区<a id="collapse-icon-Community" class="icon opend"></a></dt><dd id=panel-Community><ul><li><a href="/zh-cn/plugins/" class="menu-link">内置插件列表</a></li><li><a href="/zh-cn/contributing.html" class="menu-link">如何贡献</a></li><li><a href="/zh-cn/resource.html" class="menu-link">资源</a></li><li><a href="/zh-cn/faq.html" class="menu-link">常见问题</a></li></ul></dd></dl>
</aside>
<script>
var mobileTrigger = document.getElementById('mobileTrigger');
var mobileAside = document.getElementById('mobileAside');

var expandMenu = function(title) {
  // handle icon
  const collapseIcon = document.getElementById('collapse-icon-' + title);
  if (collapseIcon) {
    collapseIcon.className = 'icon opend';
  }
  // handle panelEle
  const panelEle = document.getElementById('panel-' + title);
  if (panelEle) {
    panelEle.className = '';
  }
}

var collapseMenu = function(title) {
  // handle icon
  const collapseIcon = document.getElementById('collapse-icon-' + title);
  if (collapseIcon) {
    collapseIcon.className = 'icon closed';
  }
  // handle panelEle
  const panelEle = document.getElementById('panel-' + title);
  if (panelEle) {
    panelEle.className = 'aside-panel-hidden';
  }
}

mobileAside.onclick = function(e) {
  const targetId = e.target.id;
  if (targetId && (targetId.indexOf('title-') > -1 || targetId.indexOf('collapse-icon-') > -1)) {
    const title = targetId.replace('title-', '').replace('collapse-icon-', '');
    try { 
      // the the browser may have no localStroage or JSON.parse may throw exception.
      const menuInfo = JSON.parse(window.localStorage.getItem('menuInfo'));
        
      // current menu status
      const curClosed = menuInfo[title] ? menuInfo[title].closed : false; // default false

      // change UI
      curClosed ? expandMenu(title) : collapseMenu(title);

      // save menuInfo to localStorage
      menuInfo[title] = { closed: !curClosed } // opposite
      window.localStorage.setItem('menuInfo', JSON.stringify(menuInfo));
    } catch (e) {}
  }
};

mobileTrigger.onclick = function(e) {
  e.preventDefault();
  if (mobileAside.className.indexOf('mobile-show') === -1) {
    mobileAside.className += ' mobile-show';
  } else {
    mobileAside.className = 'toc';
  }
};

(function() {
  // save data to localStorage because the page will refresh when user change the url.
  let menuInfo;
  try { 
    // the the browser may have no localStroage or JSON.parse may throw exception.
    menuInfo = JSON.parse(window.localStorage.getItem('menuInfo'));
    if (!menuInfo) {
      menuInfo = {};
      window.localStorage.setItem('menuInfo', JSON.stringify(menuInfo));
    }
  } catch (e) {
    menuInfo = {}; // default {}
  }

  for (const title in menuInfo) {
    if (menuInfo[title] && menuInfo[title].closed) { // menu in closed status.
      collapseMenu(title);
    } else {
      expandMenu(title);
    }
  }

  // highlight menu
  const pathname = window.location.pathname;
  const selector = `a[href="${pathname}"].menu-link,a[href="${pathname}index.html"].menu-link`;
  const menuItem = mobileAside.querySelector(selector);
  if (menuItem) { menuItem.className += ' highlight'; }
})();
</script>

</div>

  </div>
</body>
<script src="https://cdn.jsdelivr.net/docsearch.js/2/docsearch.min.js"></script>
<script>
docsearch({
  apiKey: '1561de31a86f79507ea00cdb54ce647c',
  indexName: 'eggjs',
  inputSelector: '#search-query',
});
</script>
<div class="cnzz">
<script src="https://s11.cnzz.com/z_stat.php?id=1261142226&web_id=1261142226" language="JavaScript"></script>
</div>

</html>
